Skip to content

feat(classroom): kick notification, classcode URL auto-leave, and kick request flow (Phase 0: localStorage migration)#694

Merged
takaokouji merged 10 commits into
developfrom
feature/issue-692-classroom-localstorage-migration
May 21, 2026
Merged

feat(classroom): kick notification, classcode URL auto-leave, and kick request flow (Phase 0: localStorage migration)#694
takaokouji merged 10 commits into
developfrom
feature/issue-692-classroom-localstorage-migration

Conversation

@takaokouji
Copy link
Copy Markdown

@takaokouji takaokouji commented May 21, 2026

Summary

Issue #692 のクラス管理機能改修。問題2(classcode URL 再オープン時に自分の席が押せない) の根本原因の半分(sessionStorage で参加状態を保持していた)を解消し、ドキュメント (docs/classroom/*.md) の「localStorage に永続化」記述と実装を一致させる。

  • 生徒のセッション (smalruby:classroom) を sessionStoragelocalStorage に移行
  • モジュール読込時に「旧 sessionStorage の値を 1 回だけ localStorage にコピー → sessionStorage を破棄」のマイグレーション
  • 既存 localStorage 値があれば上書きしない(多重タブ・並走を考慮)
  • 不正な JSON が残っていても安全に無視 + 旧 key をクリーン

これだけで以下が解決:

  • 新タブで ?classcode= 付き URL を開いても、すでに参加済みなら student-status に直行(自分の席が「使用中」で押せなくならない)
  • ブラウザ再起動でも参加状態を維持

Refs #692

Implementation Steps(TDD + Phase-by-Phase Commit)

Phase 0: ストレージ移行(localStorage 化)

  • [RED] test/unit/reducers/classroom-reducer.test.js を更新 — localStorage を見るように、sessionStorage→localStorage マイグレーション込みで失敗するテストを追加
  • [GREEN] src/reducers/classroom.js を更新 — loadSession/saveSession/clearStoredSessionlocalStorage 経由に変更、旧 sessionStorage 値があれば 1 回だけコピー後に削除
  • [PASS] lint + 該当 unit test (21 tests pass, 0 warnings)
  • [COMMIT & PUSH] fix(classroom): migrate session persistence from sessionStorage to localStorage
  • [MAKE PR]

Phase 1: backend — kick tombstone + verify-session 拡張

  • [RED] lambda/tests/handler.integration.test.ts に「kick 後 → verify-session 410 reason='kicked'」のテスト追加(10 ケース)
  • [GREEN] handler.tshandleDeleteMember / handleVerifySession / handleLookupClassroom / handleJoinClassroom を改修 + 新規 KickedError クラス
  • [PASS] docker compose run --rm -w /app/infra/smalruby-classroom infra npm test — 63 unit tests pass
  • [stg deploy] cdk deploy --context stage=stg 完了、integration 43/44 pass(1 件 pre-existing failure 残)
  • [COMMIT & PUSH] feat(classroom): mark kicked members and surface reason via verify-session

Phase 2: frontend — kick 通知 + 自動 seat 遷移

  • [RED] 新規 test/unit/containers/classroom-error-utils.test.js (6 ケース) + test/unit/lib/classroom-api.test.js (3 ケース) で kick 検出ロジック / API 410 body 透過を unit-test
  • [GREEN] extractKickReason() 追加、classroom-api.jserr.body を expose、classroom-modal.jsx で 410 reason='kicked' を検出して kickedNotice state を保存・seat 画面へ自動遷移、StudentSeatSelector に dismissible バナーを追加 (classroom-kicked-banner)、locales 3 言語に新メッセージ
  • [PASS] lint + 該当 unit test 30/30 pass + Playwright で localhost → stg backend で完全 kick→banner→re-join サイクル確認 (tmp/phase2-kick-banner.png)
  • [COMMIT & PUSH] feat(classroom): show kick banner and auto-navigate to seat selection on forced leave

Phase 3: frontend — classcode URL 再オープン時の自動 leave

  • [RED] 新規 test/unit/containers/classroom-classcode-utils.test.js (5 ケース) で pure な decideClasscodeAction() の挙動を検証(fresh_join / same_class / switch_class / 大文字小文字差 / classroomId 欠落フォールバック)
  • [GREEN] 新規 src/containers/classroom-classcode-utils.js 追加、classroom-modal.jsx の classcode useEffect が switch_class 時に best-effort で leaveClassroom() を発射(await しない: 遅延 / 401 で新 lookup を止めない)
  • [PASS] lint zero warnings + classroom 関連 unit test 35/35 pass
  • Playwright (localhost frontend + stg backend) で完全動作確認: クラス A に join → B の classcode URL を開く → A の members 空 / takenSeats [] / B の seat 選択へ遷移、その後 B join → B の URL を再オープン → student-status 直行(leave 呼ばれない / lastActiveAt 更新)
  • [COMMIT & PUSH] fix(classroom): leave old session on server when classcode URL points to different classroom

Phase 4: backend — kick request エンドポイント群

  • [RED] integration test 16 ケース — create (4 シナリオ) / list (初期空・1件・2件) / approve / reject / 401 認証境界 (3 件)
  • [GREEN] classroom-stack.tsClassroomKickRequests{stage} テーブル + classroomId-seatNumber-index GSI、handler.ts に 4 ハンドラ + 4 ルーティング + findClassroomWithSeatOccupied ヘルパー + validateKickRequestReason、HTTP API v2 に 4 ルート (POST /classrooms/lookup/kick-request、GET /classrooms/{id}/kick-requests、POST .../approve、DELETE .../{requestId})、KICK_REQUEST_TTL_SECONDS=3600 (1h)、approve は handleDeleteMember を呼び出して Phase 1 の tombstone 経路を再利用
  • [PASS] TypeScript clean + unit test 63/63 pass + cdk synth + cdk diff レビュー
  • [stg deploy] cdk deploy --context stage=stg 完了、integration test 59/60 pass(残 1 件は aa35f9a7b9 由来の pre-existing failure)
  • [COMMIT & PUSH] feat(classroom): kick request endpoints for student-initiated seat reclaim

Phase 5: frontend — 生徒の退室リクエスト送信

  • [RED] API 3 ケース (test/unit/lib/classroom-api.test.js) + storage 8 ケース (test/unit/lib/classroom-kick-request-storage.test.js) で createKickRequest + localStorage 永続化を unit test
  • [GREEN] classroom-api.js に 4 メソッド (create/list/approve/reject)、classroom-kick-request-storage.js 新規 (1h TTL)、classroom-modal.jsx container に席タップハンドラ + ダイアログ state + 5s polling、kick-request-confirm-dialog.jsx 新規、student-seat-selector.jsx で「使用中の席」を tap 可能 + pending バナー、locales 3 言語
  • [PASS] lint + 該当 unit test 14/14 pass + Playwright で完全動作確認 (タップ→ダイアログ→送信→pending バナー→localStorage 永続化→承認後 5s polling で席空き反映)
  • [COMMIT & PUSH] feat(classroom): allow students to request seat reclaim from teacher

Phase 6: frontend — 先生の退室リクエスト承認 / 却下 UI

  • [GREEN] use-teacher-classrooms.js に kickRequestsBySeat + approve/reject handler 追加、30 秒自動リフレッシュにも組込み、teacher-class-detail.jsx の seat cell に赤い「!」バッジ + kickRequestsForSelectedSeatteacher-member-detail.jsx に渡す、teacher-member-detail.jsx で 「承認 (kick this student)」/「却下」ボタンを描画、CSS で badge + panel デザイン、locales 3 言語 (plural-aware)
  • [PASS] lint + classroom 全 unit test 46/46 pass + Playwright で完全動作確認 (座席バッジ表示 → 「却下」でバッジ消去・メンバー残存 → 別リクエスト送信 → 「承認」でメンバー削除 + バッジ消去)
  • [COMMIT & PUSH] feat(classroom): teacher UI to approve or reject kick requests

Phase 7: Integration tests + docs + screenshots

  • Phase 1 / 4 の backend integration tests (59/60 pass) で kick flow と kick-request flow を end-to-end カバー
  • docs/classroom/{README,architecture,user-stories,source-code,testing}.md 更新、.claude/rules/scratch-gui/e2e-test.md の data-testid 表を更新
  • screenshots 4 枚撮影: 0103-menu-bar-joined.png / 0208-teacher-kick-request-panel.png / 0307-student-kicked-banner.png / 0308-student-kick-request-pending.png
  • [COMMIT & PUSH] docs(classroom): document kick notification and kick request flow

Phase DoD: CI 完了待ち + stg デプロイ + ブラウザ確認

  • gh pr checks <PR番号> --watch で CI 完了待ち
  • frontend のプレビュー URL で Playwright MCP 確認

Definition of Done

  • 全 Phase の unit test pass — scratch-gui: classroom 関連 46/46 + 全体 unit-test-gui CI green / infra: 63/63 (kick reason のテスト含む)
  • integration test (frontend / infra) pass — infra integration 59/60 (残 1 件は本 PR 以前から存在する aa35f9a7b9 由来の className expected undefined テスト)
  • lint pass (npm run lint で zero warnings) — ローカルで確認、CI lint job も green
  • CI green — 全 18 ジョブ pass (lint / unit-test-gui / unit-test-vm / integration-test-gui (1)(2) / integration-test-vm (1)(2) / build-and-deploy [prod build] / commitlint / 全 infra ジョブ)
  • stg backend デプロイ済み、infra/smalruby-classroomnpm run test:integration pass
  • localhost frontend (Phase 0-6 適用済み) + stg backend (Phase 1/4 デプロイ済み) で Playwright MCP DoD 確認:
    • (1a) 同タブリロード → localStorage に session 残る、student-status 画面へ、メニューバー「クラス:出席番号05」表示
    • (1b) 新タブで同じ classcode URL を開く → localStorage から session を読んで student-status 画面へ直行(席選択にならない、自分の席が押せないバグ消滅)
    • (2) 先生 kick → 生徒モーダルを開くと「先生によってクラスから退室させられました」バナー + 自動 seat 遷移、同じ席で再参加可 (Phase 2 で確認、screenshot 0307-student-kicked-banner.png)
    • (3) 異なる classcode URL を開く → 古いセッションがサーバ上 leave される (GET /members が空に) + 新クラスの seat 選択へ遷移 (Phase 3 で確認、A クラス members:[] / takenSeats:[] → B クラス seat 選択)
    • (4) 使用中の席タップ → 「退室を依頼」ダイアログ → 送信 → seat 画面にバナー + ボタン disabled + polling (Phase 5 で確認、screenshot 0308-student-kick-request-pending.png)
    • (5) 先生画面の対応席に「退室リクエスト」バッジ + member-detail で「承認して退室させる」→ polling で席空き反映 → 生徒が選択可 (Phase 5/6 で確認、screenshot 0208-teacher-kick-request-panel.png)
    • (6) 先生が「却下」→ リクエストが消え、生徒の依頼ボタンが再び有効 (Phase 6 で確認: 却下後 GET /kick-requests 空 + メンバー残存)
    • (7) iPad 横 (1024×768) と iPhone 縦 (375×667) でレイアウト確認 (tmp/dod-ipad-landscape-*.png, tmp/dod-iphone-portrait-*.png)
    • (8) 互換性: 旧 sessionStorage 値を手動セット → 起動時 localStorage に転記 + sessionStorage クリア、メニューバー「クラス:出席番号09」表示 (Phase 0 ユニットテスト + 最終 Playwright 確認済み)
    • (9) prod build (npm run build) で同じシナリオ — CI の build-and-deploy ジョブ (prod build) が passbuild:dev で動作確認済みコードが prod build でも問題なくバンドルされた

Test plan

  • bin/dx bash -c "cd packages/scratch-gui && npm exec jest test/unit/reducers/classroom-reducer.test.js test/unit/containers/classroom-classcode-utils.test.js test/unit/containers/classroom-error-utils.test.js test/unit/lib/classroom-api.test.js test/unit/lib/classroom-kick-request-storage.test.js" — 46 pass
  • bin/dx bash -c "cd packages/scratch-gui && npm run lint" — zero warnings
  • docker compose run --rm -w /app/infra/smalruby-classroom infra npm test — 63 pass
  • docker compose run --rm -w /app/infra/smalruby-classroom infra npm run test:integration (stg) — 59/60 pass (1 件 pre-existing)
  • CI 全 18 ジョブ pass(build-and-deploy / integration-test-gui (1+2) / integration-test-vm (1+2) / unit-test-gui / unit-test-vm / lint / commitlint / 全 infra ジョブ)
  • Playwright MCP で生徒・先生 両側の全シナリオを localhost + stg で end-to-end 確認

🤖 Generated with Claude Code

…calStorage

Student session was persisted to sessionStorage even though the docs and
the in-app behavior promised localStorage-style persistence. As a result:

- A new tab opened from a `?classcode=<code>` link saw an empty session
  and offered the seat-selection screen, where the student's own seat was
  shown as occupied (because the original tab still held the membership
  on the server).
- A browser restart silently lost the membership state on the client,
  while the server-side seat remained taken until TTL.

Move persistence to localStorage so that new tabs and browser restarts
restore the student session, matching the documented behavior. A one-shot
migration on module load promotes any pre-existing sessionStorage value
to localStorage (without overwriting an existing localStorage value) and
always clears the legacy sessionStorage entry so it stops resurfacing.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

takaokouji and others added 9 commits May 21, 2026 13:52
…ssion

When a teacher removes a student via DELETE /classrooms/{id}/members/{memberId},
the next thing the student does is open the classroom modal and watch the UI
say "session expired" — the same message they would see for a TTL expiry or a
deleted classroom. They have no way to tell that the teacher just kicked them
on purpose, and the seat-selection flow can't auto-rehydrate to let them pick
a different number.

Switch the kick from a hard delete to a tombstone so the next request from the
kicked sessionToken can be distinguished from the generic auth failure:

- handleDeleteMember now SETs `kicked=true, kickedAt, kickJoinCode,
  kickClassName, kickSeatNumber` and shortens the row's TTL to
  KICK_TOMBSTONE_TTL_SECONDS (1h). If the row was already gone, we treat the
  request as success (idempotent).
- verifySessionToken throws a new KickedError when it sees `kicked=true`. The
  top-level handler converts KickedError to a 410 response carrying
  `{reason: 'kicked', joinCode, className, seatNumber}` so the student UI can
  navigate straight back into seat selection for the same classroom.
- handleListMembers and handleLookupClassroom add a FilterExpression to skip
  tombstones — the seat appears free to the teacher's grid and to the
  takenSeats list immediately after the kick.
- handleJoinClassroom relaxes its ConditionExpression to
  `attribute_not_exists(memberId) OR kicked = :true`, so a freshly kicked seat
  can be re-occupied without a separate cleanup pass. The new row drops the
  kick attributes, so the old sessionToken naturally stops resolving via the
  sessionToken-index and falls back to the standard 401 path.

Integration tests added against the stg endpoint exercise the full sequence
(join → verify=200 → kick → verify=410 + payload → listMembers excludes →
lookup takenSeats excludes → another student joins same seat → original
sessionToken now resolves to 401).

Note: one pre-existing test failure remains in the lookup describe block
(expects `data.className`/`assignmentName` to be undefined while the handler
returns them); it predates this commit (added by aa35f9a) and is left for
a separate cleanup.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on forced leave

Pair with the Phase 1 backend change so a student who was kicked by the
teacher gets a specific in-modal banner instead of the generic "session
expired" alert, and lands on the seat-selection screen for the same
classroom — ready to pick a different seat in one click instead of
having to re-type the join code.

- classroom-api.js: attach the parsed response body to thrown Errors so
  callers can read response-specific fields (the 410 kick body carries
  reason/joinCode/className/seatNumber).
- classroom-error-utils.js: new `extractKickReason(err)` helper that
  returns the kick context for a 410 + reason='kicked', or null
  otherwise. Pure function, unit-tested in isolation.
- classroom-modal.jsx (container): when refreshStudentStatus catches an
  error, detect kick via extractKickReason. On kick: clear the local
  session, save a `kickedNotice` state, and call handleJoinWithCode with
  the kicked classroom's joinCode — which lookups the seat grid and
  flips phase to student-seat. On non-kick errors, keep the existing
  generic-alert path.
- ClassroomModal (component) + StudentSeatSelector: forward kickedNotice
  and onDismissKickedNotice. The selector renders a dismissible orange
  banner above the seat grid ("先生によってクラスから退室させられました
  ...") using the existing CSS-module styling vocabulary.
- locales: add `gui.classroom.kicked.banner.title` and `.subtitle` in
  en / ja / ja-Hira.
- Tests:
  * 6 cases for extractKickReason (happy path, missing body, non-kick
    410, null/undefined err, …)
  * 3 cases for classroom-api error-body propagation
- Playwright smoke verified end-to-end on localhost against the deployed
  stg backend: join seat 2 → teacher kicks via API → reopen modal →
  banner appears + seat-selection auto-displayed + seat 2 immediately
  re-selectable → re-join lands student back in student-joined.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… to different classroom

Previously, opening a `?classcode=<other>` URL while still holding a
session for a different classroom only cleared the local Redux state —
the previous seat stayed occupied on the server until TTL, so a
returning student or the teacher's grid kept treating the old seat as
"taken." With the Phase 0 move to localStorage this became much more
likely to bite, since the session survives tab close / browser restart.

Extract the URL-parameter decision into a pure helper
`decideClasscodeAction()` and unit-test it across the five cases:
fresh_join (no session), same_class (matching joinCode), switch_class
(different joinCode → release old seat + re-lookup), case-insensitive
join code match, and the malformed-state fallback (sessionToken but no
classroomId).

Wire the helper into the classcode useEffect so the switch_class branch
fires `classroomAPI.leaveClassroom(oldToken, oldClassroomId)` as a
best-effort, non-blocking call. We do not await it: a slow API or a
401 (e.g. server already expired the session) must not stall the new
lookup or block the student from joining the new classroom. The new
join takes precedence either way.

Playwright verified on localhost frontend against the deployed stg
backend:

  1. Join class A (seat 3) → `GET /members` shows seat-03 on A.
  2. Navigate to `?classcode=<B>` (different class) → land on seat
     selection for B, `GET /members` on A is now empty, lookup
     `takenSeats` on A returns [].
  3. Join class B (seat 2) → revisit `?classcode=<B>` → straight to
     student-status (same_class branch, no leave fired, lastActiveAt
     ticked on B).

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
While joined, the menu-bar button used to render
"{assignmentName || className} / {seatNumber}" (e.g. "第1回チャットアプリ
を作ろう / 03"). In user testing this turned out to be confusing — the
left half looked like a project title and it wasn't obvious that the
two-digit suffix was the seat number rather than a version or a date.

Replace the joined label with a fixed-format "クラス:出席番号NN" (English:
"Class: Seat NN"). The class / assignment name still lives in the modal
once opened, but is dropped from the always-visible bar so the bar
unambiguously identifies *what the number means*. The unjoined bar
remains "クラス" alone.

- menu-bar.jsx: switch the joined branch to a single FormattedMessage
  `gui.menuBar.classroomJoined` (with the seat-number span passed as a
  formatter value so the `classroom-menu-seat-number` testid is
  preserved for Playwright). The old `classroom-menu-class-name` span
  is removed — no test or tool referenced it.
- locales: add `gui.menuBar.classroomJoined` in en/ja/ja-Hira.
- docs: update docs/classroom/ui-ux.md, docs/classroom/testing.md and
  .claude/rules/scratch-gui/e2e-test.md to match the new format and
  drop the removed testid.

Playwright verified end-to-end on localhost: not-joined → "クラス";
joined to seat 4 → "クラス:出席番号04"; `classroom-menu-seat-number` is
still a child span; `classroom-menu-class-name` is no longer in the
DOM.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…claim

Phase 4 of #692 — student-driven path for the case where a wrong-seat
classmate is occupying the seat the student wants. The student opens
the seat-selection screen, taps their own seat (currently grey/taken),
fires a kick request to the teacher, and waits; the teacher sees the
request next to the seat in the class-detail view and either approves
(which kicks the occupant, reusing Phase 1's tombstone path so the
kicked student gets the "you were removed" banner) or rejects (the
request disappears, the occupant stays).

CDK:
- New `ClassroomKickRequests{stageSuffix}` DynamoDB table with
  PK=classroomId, SK=requestId (UUID) so a single seat can hold multiple
  pending requests simultaneously. A `classroomId-seatNumber-index` GSI
  lets the approve handler delete all sibling requests for the same
  seat in one query so duplicates don't ghost-linger after a kick.
- KICK_REQUESTS_TABLE_NAME env wired into the Lambda; grantReadWriteData
  added.
- Four new HTTP API v2 routes:
    POST /classrooms/lookup/kick-request (no auth — joinCode + seatNumber)
    GET  /classrooms/{classroomId}/kick-requests (teacher auth)
    POST /classrooms/{classroomId}/kick-requests/{requestId}/approve
    DELETE /classrooms/{classroomId}/kick-requests/{requestId} (= reject)

Handler:
- KICK_REQUEST_TTL_SECONDS = 3600 (1 hour); rows expire automatically.
- MAX_KICK_REQUEST_REASON_LENGTH = 200; `validateKickRequestReason()`
  trims and enforces.
- `findClassroomWithSeatOccupied()` refuses requests for a seat that
  is already empty (or only holds a kick tombstone) so the teacher
  doesn't see noise from misclicks or stale clients.
- `handleApproveKickRequest()` calls into the existing
  `handleDeleteMember()` so the kicked student hits exactly the same
  410 reason='kicked' verify-session path as a direct teacher kick,
  then batch-deletes every kick request that targeted the same seat
  (the approved one and any duplicates).
- `handleRejectKickRequest()` deletes the single request row; the
  occupant is untouched.
- Anonymous create endpoint reuses the join-IP rate limiter; explicit
  abuse-prevention beyond that (e.g. dedupe by IP) is intentionally
  skipped per the Issue's "規制なし — 複数件のリクエストを許可" decision.

Integration tests against the deployed stg endpoint cover the full
flow plus auth boundaries — 60/60 new+existing tests green except for
one pre-existing failure (`/classrooms/lookup` expecting className to
be undefined, see aa35f9a) unrelated to this change.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the student-side UI for the Phase 4 kick-request endpoints. When a
student opens the seat-selection screen and finds the seat they actually
want already occupied (e.g. someone picked the wrong seat number first),
they can now tap the grey-tinted seat to send a request to the teacher.

- classroom-api.js: add createKickRequest, listKickRequests,
  approveKickRequest, rejectKickRequest methods.
- classroom-kick-request-storage.js: small localStorage helper that
  persists {requestId, joinCode, seatNumber, reason, createdAt} so the
  "依頼中" state survives a tab close or reload. Records older than the
  backend's 1h TTL are dropped on load so we don't keep showing the
  pending banner forever.
- StudentSeatSelector: render occupied seats as tappable when an
  onRequestKick handler is provided AND no pending request is already
  outstanding (we cap at one in-flight request to keep the teacher's
  view manageable). Show a blue "Waiting for the teacher to free seat
  NN..." banner above the grid while a request is pending. The dialog
  replaces the grid (vs. overlaying) so users on small viewports can
  still tap the buttons.
- KickRequestConfirmDialog (new): confirm dialog with an optional
  reason textarea (200 char max enforced both client- and server-side)
  and Cancel / Send buttons. The dialog inherits the modal's container
  styling so it doesn't introduce a new portal.
- classroom-modal.jsx container: holds dialog/pending state, calls the
  API, persists to localStorage, and polls lookupClassroom every 5s
  while a request is outstanding. When the targeted seat is no longer
  in takenSeats, the teacher acted (approve = handleDeleteMember
  kicked the occupant; reject = request just disappeared from the
  list; TTL = backend GC'd it after an hour) — we clear pending state
  so the student can re-pick the seat. Submitting a successful join
  also clears pending state.
- locales: gui.classroom.kickRequest.{title,body,reasonPlaceholder,
  cancel,submit,pendingBanner} in en/ja/ja-Hira.

End-to-end Playwright check on localhost against stg backend:
  1. seat 2 occupied by another student via API.
  2. Open `?classcode=...` → student-seat. Seat 2 is grey but
     clickable; `data-taken="1"` exposed for tests.
  3. Tap seat 2 → kick-request-confirm-dialog opens with title
     "出席番号02の人に退室を依頼しますか?".
  4. Type "私の席です" reason → submit → 201 from
     /classrooms/lookup/kick-request. Banner appears, seat 2 is no
     longer tappable (`data-taken="0"`), localStorage holds the
     pending record.
  5. Teacher approves via API. Within one polling tick (5s) the
     banner disappears, seat 2 becomes available, localStorage is
     cleared, and the student can now select it.

Unit tests added (14 total):
  * createKickRequest: payload shape, omits reason, no Authorization
    header
  * Storage helper: load/save/clear, stale-record drop, malformed
    JSON, missing fields, no-op for invalid input

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the teacher's side of the Phase 4-5 kick-request flow into the
existing class-detail screen. The seat grid shows a red "!" badge on
each occupied seat that has any pending request, and the member-detail
panel (which opens when the teacher clicks a seat) gains a yellow
"requests from students" panel listing each request's optional reason
with per-request Approve / Reject buttons.

Approve calls /classrooms/{id}/kick-requests/{rid}/approve which
already triggers the same kick path as the manual Remove button — the
kicked student gets the 410 reason='kicked' verify-session response
and the seat opens up in `lookupClassroom` for whoever was waiting.
Reject just deletes the request row; the occupant stays.

- use-teacher-classrooms.js: parallel-fetch listKickRequests alongside
  listMembers + listSubmissions on both initial load and the 30s
  auto-refresh. Group by seatNumber for fast lookup in the renderer.
  Add `handleApproveKickRequest` / `handleRejectKickRequest`, which
  also call `refreshMembersOnly` so the seat grid and member panel
  reflect the kick / cleared request without an extra round trip.
  Reset kickRequestsBySeat in resetClassrooms, handleBackToDashboard.
- use-teacher-classroom.js: forward the new state + handlers through
  the aggregator.
- classroom-teacher-modal (container + presentational): pass
  kickRequestsBySeat / onApproveKickRequest / onRejectKickRequest
  through to TeacherClassDetail.
- TeacherClassDetail: add a red "!" badge (`seat-kick-request-badge`)
  to each member cell with pending requests; expose seat-level
  `data-testid="classroom-seat-kick-request-{N}"` for tests. Compute
  the per-seat slice of kickRequestsBySeat for the selected seat and
  pass it to TeacherMemberDetail.
- TeacherMemberDetail: render a `kick-request-panel` below the Remove
  button when the selected seat has pending requests, with one row
  per request (reason or "(no reason given)" placeholder, plus
  per-request Approve / Reject buttons). Buttons are bound via stable
  callbacks to satisfy `react/jsx-no-bind`.
- classroom-modal.css: `seat-kick-request-badge` (absolute-positioned
  red dot, requires the cell to be `position: relative` — added to
  `.member-cell`), and the yellow `kick-request-panel` plus inner
  styles for the approve (red) / reject (light) buttons.
- locales: gui.classroom.kickRequest.{teacherTitle,noReason,approve,
  reject} in en/ja/ja-Hira (teacherTitle is plural-aware via ICU).

End-to-end Playwright check against deployed stg:
  1. Teacher logs in via devlogin, opens a class with seat 3 occupied
     and one outstanding kick request.
  2. Seat-3 cell shows the red "!" badge.
  3. Clicking seat 3 opens the member detail; "1 件の退室リクエストが
     届いています" header + "「これは私の席です」" reason + Approve /
     Reject buttons.
  4. Reject → request gone, badge cleared, member still seated;
     `GET /kick-requests` empty, `GET /members` still has seat-03.
  5. Send a new request via curl. Refresh → badge reappears. Approve
     → seat-03 removed from `GET /members`, request cleared, badge
     gone.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the classroom docs in sync with the Phase 1-6 implementation:

- README: new "強制退室通知と退室リクエスト" section pointing readers at
  the new flows and noting the sessionStorage → localStorage migration
  + classcode auto-leave behavior.
- architecture.md: API route table now lists
  /classrooms/lookup/kick-request and the three teacher kick-request
  endpoints; verify-session is documented to return 410 with reason
  for kicked sessions. New ClassroomKickRequests table (PK
  classroomId / SK requestId, classroomId-seatNumber-index GSI, TTL
  1h) and the membership kick-tombstone shape are described in their
  own subsections.
- user-stories.md: two new student stories (request the teacher free a
  seat / handle being kicked with the in-modal banner) and one new
  teacher story (approve/reject pending kick requests in the
  member-detail panel).
- testing.md + .claude/rules/scratch-gui/e2e-test.md: data-testid
  tables list every new selector
  (classroom-kicked-banner, kick-request-confirm-dialog,
  kick-request-reason-input, kick-request-submit/cancel/error,
  kick-request-pending-banner, classroom-seat-kick-request-{N},
  classroom-member-kick-request-panel,
  classroom-kick-request-row/approve/reject-{requestId}).
- source-code.md: new file entries (kick-request-confirm-dialog.jsx,
  classroom-kick-request-storage.js); classroom-api.js method count
  bumped 20 → 24.
- screenshots/: add four new captures
  (0103 menu-bar joined, 0208 teacher kick-request panel,
   0307 student kicked banner, 0308 student kick-request pending).

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…jected

Two follow-up fixes after the first round of preview-URL verification:

1) **Reject UX**: a student who sent a kick request used to watch the
   "依頼中です…" banner for up to an hour after the teacher hit Reject —
   the banner only cleared once the polling lookup observed the seat go
   empty, which never happens on rejection. Surface active kick-request
   IDs from the public lookup endpoint so the student's 5-second poll
   can also detect "my request is gone but the seat is still taken =
   rejected (or TTL'd)" and swap the pending banner for a red "出席番号NN
   の退室は受理されませんでした。別の席を選ぶか、もう一度依頼を出してくだ
   さい。" banner with a × dismiss button. The same banner clears
   automatically on a new request submission or a successful join.

   - handler.ts `handleLookupClassroom`: also query the kick-requests
     table and return `activeKickRequestIds: string[]` alongside the
     existing fields. The IDs are UUIDs the client already received
     from `/lookup/kick-request`, so no new auth boundary.
   - integration test: covers (a) ID present after submit,
     (b) absent after reject.
   - classroom-modal container: polling now branches on
     `seatStillTaken` × `requestStillActive` and drives a new
     `kickRequestRejectedNotice` state.
   - StudentSeatSelector + classroom-modal component: new banner
     rendered when the rejected notice is set and no pending banner is
     active. Banner is dismissible
     (`kick-request-rejected-banner-dismiss`) and also clears when the
     student opens a fresh kick dialog or successfully joins another
     seat.
   - locales: `gui.classroom.kickRequest.rejectedBanner` in en / ja /
     ja-Hira.

2) **Seat color alignment**: the teacher's class-detail grid showed
   empty = grey and occupied = blue, which was the inverse of the
   student's seat-selection grid (empty = blue, occupied = grey).
   Swap the two member-cell color classes so the role-independent
   rule "blue = available, grey = occupied-but-no-submission" holds on
   both sides. Submitted (green) and returned (orange) keep their
   teacher-only colors.

End-to-end Playwright check against the updated stg backend:
  1. Student joins seat 2 → seat is grey in the teacher view.
  2. Another student taps seat 2 → kick-request submitted, pending
     banner appears, server returns the requestId in
     `activeKickRequestIds`.
  3. Teacher rejects → within ≤ 5 s the student's polling tick swaps
     the blue pending banner for the red rejected banner; pending
     cleared from localStorage; seat 2 is tappable again so the
     student can send a new request if needed.

Refs #692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@takaokouji takaokouji merged commit cea869c into develop May 21, 2026
18 checks passed
@takaokouji takaokouji deleted the feature/issue-692-classroom-localstorage-migration branch May 21, 2026 13:30
github-actions Bot pushed a commit that referenced this pull request May 21, 2026
…-692-classroom-localstorage-migration

feat(classroom): kick notification, classcode URL auto-leave, and kick request flow (Phase 0: localStorage migration)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant